feat(schedule): add gaia schedule CLI + cron-based dispatch#1371
feat(schedule): add gaia schedule CLI + cron-based dispatch#1371TravisHaa wants to merge 4 commits into
Conversation
GAIA had no way to run a skill or prompt on a recurring schedule, so every recurring use case (morning brief, hourly watcher, weekly digest) needed an external cron plus manual wiring. This adds a `gaia schedule` command group backed by a hand-editable ~/.gaia/schedules.toml store and an APScheduler daemon: register a prompt with a cron expression and the agent runs it on schedule in a fresh session, delivering output to a configurable sink (stdout, file, desktop notification, or Telegram). The --prompt path is fully wired; --skill raises an actionable error pending the skill format (amd#888). Sinks fail loudly on delivery errors per the no-fallback rule. Tests, CLI docs, and the optional OS service installers are follow-ups, not included here.
|
Heads-up: we're also landing a scheduler panel in the Agent UI, salvaged from #517 (NL intervals, REST API, run history in chat sessions). Paths are disjoint from this PR ( |
Sorry for the delay, it's finals week for me so it's been a bit busy. I'll have this PR done by next Friday at the latest. Also, I was wondering what the single store should be, so I can mold the structure of the scheduling core to it? |
|
No rush — good luck with finals. Next Friday is fine. On the single store: let's make SQLite the canonical runtime store and keep your TOML as the hand-editable declarative layer on top of it, rather than picking one and dropping the other. Here's the split that lets both surfaces converge cleanly:
So for this PR: keep building the cron core + CLI/sinks against your One open question for you: do you want the daemon to own scheduling for both surfaces, or should the UI keep its own in-process scheduler short-term? Happy to sync on that before you finalize. |
## Why this matters
GAIA had no way to run anything on a schedule from the Agent UI — every
recurring use case ("every morning at 9, summarize my inbox") needed
external tooling. This adds a Schedule Manager to the UI: describe a
schedule in natural language ("every 30m", "daily at 9pm", "every monday
at 8am"), attach a prompt, and the agent runs it on time through the
local LLM, with each schedule's full run history living in its own chat
session. Salvaged from amd#517 and rebuilt on current main with fail-loudly
hardening (malformed configs raise instead of silently never firing; the
scheduler refuses to boot with lost tasks; no dry-run no-op path), a
concurrency cap, and the same tunnel security gate the autonomous agent
loop uses.
Scope notes: NL intervals only — cron syntax and heartbeat tiers stay
with the autonomy-engine plan, and the `gaia schedule` CLI (amd#1371)
remains a separate complementary surface (coordination comment posted
there; store unification is a follow-up for whichever lands second).
## Test plan
- [x] 54 scheduler unit tests (`tests/unit/test_scheduler.py`)
- [x] 18 REST API tests (`tests/unit/test_scheduler_api.py`)
- [x] 13 integration tests incl. restart persistence + full API
lifecycle (`tests/integration/test_scheduler_e2e.py`)
- [x] 6 new ChatDatabase persistence tests
- [x] Full unit suite: 5491 passed, 0 new failures (6 pre-existing on
main)
- [x] `tsc --noEmit` clean + frontend build
- [x] Manual golden path via Playwright: Clock button → panel → NL parse
preview → create → countdown → pause/delete with confirm
- [x] Boot smoke test: `/api/schedules` CRUD round-trip on a live server
- [ ] Live end-to-end run with Lemonade (create "every 1m" schedule,
verify response lands in the schedule's session)
---------
Co-authored-by: kovtcharov-amd <kalin.ovtcharov@amd.com>
Co-authored-by: Ovtcharov <kovtchar@amd.com>
Co-authored-by: Tomasz Iniewicz <itomek@users.noreply.github.com>
|
I think it would intuitively make more sense if the daemon owned scheduling for both surfaces to reduce both scheduling race conditions/double-fires. But making the CLI daemon process the owner has its own trap, as the UI server is already a long-lived process, so if UI schedules only fire when a separate daemon is also up, users get silent no-ops. So I've kept |
Prepare the schedule store to converge with the Agent UI's SQLite store (amd#1566) and close the test/doc gaps the initial CLI PR deferred. - Schedule gains last_run/next_run/session_ref so one dataclass serves both the hand-editable TOML layer and the future SQLite/run-history store; TOML serialization omits each field when unset. - ScheduleStore becomes a runtime_checkable Protocol (load/save/add/remove/get/set_enabled/mark_run) and the TOML implementation is renamed TomlScheduleStore, so a database-backed store can drop in without changing the daemon or CLI. - mark_run records last_run/next_run after a successful fire (daemon and `gaia schedule run`); a failed fire still logs loudly and leaves run-state untouched, keeping the daemon alive. - build_scheduler now depends only on the Protocol surface (no concrete store.path access) so the UI server can embed the same engine on a background thread instead of requiring a separate daemon process. - Add hermetic unit tests for the store, sinks, and daemon (LLM and network mocked), and document `gaia schedule` in docs/reference/cli.mdx.
# Conflicts: # setup.py # src/gaia/cli.py
|
🟡 # save() with POSIX advisory locking (Linux/macOS)
import fcntl
def save(self, schedules: Dict[str, Schedule]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.touch()
with open(self.path, "r+b") as f:
fcntl.flock(f, fcntl.LOCK_EX)
doc = {"schedules": {s.name: s.to_toml_dict() for s in schedules.values()}}
f.seek(0)
f.truncate()
tomli_w.dump(doc, f)
|
|
Nice work, @TravisHaa — and it tracks the plan from the thread cleanly (canonical CI is all red from pre-existing 🔍 Technical details🟡 Should-fix
🟢 Nits
|
|
🟡 Two newly-introduced issues: 1. Invalid cron crashes the daemon for all schedules, not just the broken one. 2. Telegram bot token can leak into logs via requests exception messages. 🔍 Technical detailsCron validation — Add early validation before from apscheduler.triggers.cron import CronTrigger
if action == "add":
try:
CronTrigger.from_crontab(args.cron)
except ValueError as e:
print(f"Invalid cron expression {args.cron!r}: {e}", file=sys.stderr)
return
...
store.add(schedule)APScheduler raises Token leak — # current (leaks token via {e})
except requests.RequestException as e:
raise RuntimeError(
f"telegram sink could not reach {TELEGRAM_API}: {e}. ..."
) from erequests'
Fix — omit except requests.RequestException as e:
raise RuntimeError(
f"telegram sink could not reach {TELEGRAM_API} (sendMessage). "
f"Check network connectivity."
) from e |
## Why this matters The v2 sidecar-first architecture (amd#1913) is signed-in design, but nothing yet tells an implementer where to start, what's reusable vs. net-new, or which unsigned decisions block which work — so the build can't be parallelized or even safely begun. This adds the implementation decomposition: a code-verified delta table for every v2 building block, 19 sequenced issue proposals (with acceptance criteria, dependencies, S/M/L sizes — proposals only, none filed), a specced first vertical slice (headless daemon + supervised email sidecar + `/query` streamed to the CLI), and the three sign-off decisions (§0.4 confirmation model, §0.9 custody home, §0.24 containment 🔒) as explicit questions. It also pins verified design-vs-merged deviations so implementers don't trip on them: `gaia api` still mounts email in-process (the pattern amd#1910 removed from the UI only), the only scheduler on main is UI-internal (PR amd#1566 — `gaia schedule`/PR amd#1371 is unmerged), and contract 2.1 has no `/query`, no auth (amd#1706), and no `/shutdown`. ## Test plan - [ ] Docs-only — no code paths touched - [ ] Spot-check the delta table's file/LoC claims against `main` @ `080e7259` (all were verified while writing) - [ ] Confirm the issue proposals don't duplicate existing open issues (checked: amd#1706, amd#1653, amd#1896, amd#1717 are linked, not duplicated)
The morning triage card existed only on demand — a user had to ask for a pre-scan every time. Now the email sidecar can generate it on a daily schedule with no prompt: each run reuses the agent's own `pre_scan_inbox` path (same `email_pre_scan` envelope, nothing re-classified), persists it with a `generated_at` stamp, and any surface pulls the latest run from the new additive `GET /v1/email/briefing`. Off by default — enabled explicitly with `GAIA_EMAIL_BRIEFING_ENABLED=true` (fire time `GAIA_EMAIL_BRIEFING_TIME`, default 08:00); an invalid value fails sidecar startup loudly instead of guessing a schedule. The `gaia schedule` dispatcher (amd#1371) is approved but not merged, so the in-process timer is deliberately minimal and email-scoped: `run_briefing_job()` is the one-shot seam the amd#1371 dispatcher / autonomy engine (amd#555) will invoke directly when they land, at which point the timer can be deleted without touching the job or delivery. `SCHEMA_VERSION` stays 2.1 (additive, like amd#1887). Closes amd#1608 (capability 16 in amd#1691). ## Test plan - [x] AC: scheduled job invokes `pre_scan_inbox` and produces the `email_pre_scan` envelope — `test_briefing_job_produces_email_pre_scan_envelope` - [x] AC: disabled schedule produces no briefing (no task, no job run, nothing persisted) — `test_disabled_schedule_produces_no_briefing` - [x] Config is explicit + fail-loud: off when unset; `maybe`/`8am`/`0`/`101` each raise at startup — `test_config_invalid_values_fail_loud` - [x] `GET /v1/email/briefing`: 404 before the first run, conforms to `EmailBriefingResponse` after one (`tests/test_email_openapi_conformance.py::test_briefing_conforms_to_spec`) - [x] `python -m pytest hub/agents/python/email/tests/ tests/test_email_openapi_conformance.py tests/unit/agents/email/ tests/unit/email/` — 762 passed - [x] `python -m gaia_agent_email.export_openapi --check` (artifact regenerated) · `python util/lint.py --all` clean - [x] Sidecar golden path: booted `packaging/server.py`'s app with the lifespan — health OK, briefing 404 with actionable detail, invalid env aborts startup with `BriefingConfigError`
Why this matters
GAIA had no way to run a skill or prompt on a recurring schedule, as every recurring use case like a morning brief needed an external cron plus manual wiring. This adds a
gaia schedulecommand group: register a prompt with a cron expression and the agent runs it on schedule in a fresh session, delivering output to a configurable sink (stdout, file, desktop notification, or Telegram), persisted in a hand-editable~/.gaia/schedules.tomlthat survives restarts.This is the core minimal scheduler meant to fit in one PR. The
--promptpath is fully wired;--skillraises an actionable error pending the skill format (#888). Sinks fail loudly on delivery errors per the no-fallback rule.Draft — not yet included (follow-ups): unit/integration tests, CLI docs (
docs/reference/cli.mdx+docs/docs.json), and the optional OS-service installers (systemd/launchctl/schtasks).Test plan
gaia schedule add --name hello --cron "* * * * *" --prompt "say hello" --sink stdoutgaia schedule listshows the schedule with a next-fire timegaia schedule run hellofires once and prints the agent outputgaia schedule pause hello/resume hellotogglesenabledin~/.gaia/schedules.tomlgaia schedule remove hellocleanly deletes it from the storegaia schedule daemonfires the job once per minuteCloses #892.